DEVELOPER SPECIFICATION · v1.0 · CURRENT

📍 ที่อยู่ — Address Hierarchy

เอกสารสเปคสำหรับทีมพัฒนา Frontend + Backend · หน้าจัดการข้อมูล ที่อยู่ตามทะเบียนราษฎร์ไทย 4 ระดับ (จังหวัด · อำเภอ/เขต · ตำบล/แขวง · รหัสไปรษณีย์) · อ้างอิงมาตรฐาน TIS-1099 + DOPA · ใช้เป็น base reference table สำหรับ Company Info · User Profile · WBS · Project · เฉพาะ Platform Admin เท่านั้น

🏷️ Doc ID: ADDR-SPEC-2026-001 📅 3 มิถุนายน 2569 👤 วุธ (PM / SA) 📊 77 จว · 928 อำเภอ · 7,400+ ตำบล 🎯 Frontend + Backend
📐
VERSION CONTROL
Version 1.0 ★ Current
Owner: วุธ (PM / SA) Tech Stack: ตาม SystemArchitecture v2.0

📑Table of Contents

📋01Overview & Scope

หน้า "ที่อยู่" (Address Hierarchy Settings) เป็นหน้าผู้ดูแลระบบสำหรับจัดการข้อมูลทะเบียนราษฎร์ไทย 4 ระดับ · ใช้เป็น base table สำหรับฟิลด์ที่อยู่ในทุกหน้าของระบบ (Company Info · User · WBS · Project · Invoice)

🎯 วัตถุประสงค์ (Purpose)

  • จัดการ Tree 4 ระดับ: จังหวัด (Province) → อำเภอ/เขต (District) → ตำบล/แขวง (Subdistrict) → รหัสไปรษณีย์ (Postal Code)
  • เพิ่ม · แก้ไข · ลบ · เปิด/ปิด active · ค้นหา (พิมพ์ไทย + EN + postal code)
  • นำเข้า CSV จาก DOPA (กรมการปกครอง) · ตามมาตรฐาน TIS-1099 · annual update
  • ส่งออก CSV / Excel / JSON สำหรับ backup + integration
  • ดูสถิติ count แต่ละระดับ (77 · 928 · 7,400+ · 5,400+)
  • รองรับ ภาษาคู่ TH (default) + EN (Romanization ตาม Royal Thai General System of Transcription)
  • Bulk update postal code (สรรพากร update postal ใหม่)

📐 Scope

หัวข้อScopeOut of Scope
CRUD Address4 ระดับ · TIS-1099 codes · TH + EN names · postal · active flagAuto-sync จาก DOPA API (Phase 2)
Tree managementExpand/collapse · search filter · highlight matchDrag-drop reorder (alphabetical fixed)
Import/ExportCSV (UTF-8 BOM) · Excel · JSON · ETL previewXLSX with formulas · external integrations
ValidationTIS-1099 code format · postal 5 digits · uniquenessAddress verification API (Phase 2)
MultilingualTH + EN · auto-suggest EN จาก TH (Royal Romanization library)Chinese · Burmese · Lao
ผู้ใช้งานPlatform Admin เท่านั้น · ระดับ system

👥 ผู้มีสิทธิ์ (per Role&Privilege.docx · "ที่อยู่")

บทบาทRCEDหมายเหตุ
Platform Adminเห็น/แก้ system-wide · base table shared ทุก org
Organize Adminไม่เห็นเมนู (typeahead ใช้ได้ผ่าน /addresses/subdistricts)
PM · QC Mgr · QC Insp · OP/Sub · CEOไม่เห็นเมนู

🏛️ Thai Address Standards Reference

StandardDescriptionUsed In
TIS-1099-2563มอก. รหัสมาตรฐานการแบ่งเขตการปกครองไทย · 6-digit (PP-AA-SS)subdistrict_id · pk
DOPA / กรมการปกครองข้อมูลทะเบียนราษฎร์ที่ official · ปรับปรุงรายปีSource for CSV import
Royal Romanizationระบบถอดอักษรไทยเป็นโรมัน (RTGS)EN names auto-suggest
5-digit Postalไปรษณีย์ไทย · PP-XXX formatpostal_code field
💡 Base table — global shared: Address ไม่มี org_id · ใช้ร่วมทุก organization · update centrally โดย Platform Admin เท่านั้น

⚙️02Tech Stack

อ้างอิงจาก ConstructQ_SystemArchitecture_v2.0.html

⚛️ Frontend (Web)

LayerLibraryการใช้งานในหน้านี้
FrameworkNext.js 14 (App Router · RSC)Route /setting/master/address
UI LibraryAnt Design 5.xTree · Form · Input · Drawer · Switch · Upload · Modal · Tag
StylingTailwind CSS 3.xSplit layout (tree L + detail R) · responsive · token-based
Data FetchingSWR 2.xuseSWR + virtualization for large tree
StateZustand 4addressStore · {tree, selected, filter, dirty}
FormReact Hook Form + Zodschema validation
Virtualizationreact-windowrender 7,400+ rows · ≤ 100 DOM nodes
Excel I/OSheetJS (xlsx)read CSV/XLSX → preview rows ก่อน import
Romanizationth-romanize (custom)auto-suggest EN จาก TH ตาม RTGS
i18nnext-intlTH default · EN toggle
Authnext-auth + KeycloakJWT · role check platform_admin เท่านั้น

⚙️ Backend (API)

LayerLibraryการใช้งาน
RuntimeGo 1.22Service cmd/api/main.go
Routergo-chi/chi v5Route group r.Route("/api/v1/addresses", ...)
DB Driverjackc/pgx v5CopyFrom สำหรับ bulk import 7,400 rows ใน < 2s
Validationvalidator/v10 + customTIS-1099 code · postal regex
JWTgolang-jwt/jwt v5verify + role enforcement
Excel I/Oxuri/excelize v2CSV/XLSX read + write · UTF-8 BOM
Loggerrs/zerologJSON · request_id + user_id
Event BusNATSpublish address.changed → invalidate caches
Cronrobfig/cron v3nightly: VACUUM + ANALYZE · reindex GIN

🐘 Data Layer

StoreTechการใช้งาน
PrimaryPostgreSQL 15+1 table: th_addresses · ~7,400 rows · stable
SearchPostgreSQL pg_trgmGIN index บน subdistrict_th · district_th · province_th
CacheRedis 7addr:tree:all TTL 24h · addr:typeahead:{q} TTL 1h
Cache CDNCloudflare CDNGET /api/v1/addresses?format=public · static JSON · max-age 24h
AuditTrigger immutableaudit_logs · ห้าม UPDATE/DELETE
BackupDaily PG dumpMinIO archive bucket · retention 90d

🎨03UI Layout & Components

ภาพรวมหน้าจอ + Component หลัก พร้อมตำแหน่ง

📸 Capture · หน้าหลัก (Address Tree + Detail Panel)
ตั้งค่า › ข้อมูลตั้งต้น › ที่อยู่
📍 ที่อยู่
จัดการข้อมูลที่อยู่ตามมาตรฐานทะเบียนราษฎร์ไทย — จังหวัด · อำเภอ/เขต · ตำบล/แขวง · รหัสไปรษณีย์
📥 นำเข้า CSV 📤 ส่งออก ➕ เพิ่มจังหวัด
🏛️ จังหวัด Province 🏘️ อำเภอ/เขต District 📌 ตำบล/แขวง Sub-district 📮 รหัสไปรษณีย์ 5 หลัก
🏛️
77
จังหวัด
🏘️
928
อำเภอ/เขต
📌
7,436
ตำบล/แขวง
📮
5,463
รหัสไปรษณีย์
▼ 🏛️ เชียงใหม่ 50
▼ 🏘️ อำเภอเมืองเชียงใหม่
📌 ตำบลศรีภูมิ 50200
📌 ตำบลพระสิงห์ 50200
📌 ตำบลหายยา 50100
▶ 🏘️ อำเภอแม่ริม 11
▶ 🏛️ ขอนแก่น 40
▶ 🏛️ ชลบุรี 20
ตำบล/แขวง · TIS-1099 รหัส 500101
📌 ตำบลศรีภูมิ
💾 บันทึก 🗑️ ลบ
รหัส TIS-1099 (read-only)
500101
รหัสไปรษณีย์ *
50200
ชื่อภาษาไทย *
ตำบลศรีภูมิ
ชื่อภาษาอังกฤษ * 🤖 auto
Si Phum
สถานะ Active
เปิดใช้งาน

🧩 Component Tree

<AddressPage>                                  // app/setting/master/address/page.tsx (RSC)
  <Breadcrumb />
  <PageHeader title="ที่อยู่" />
  <ActionBar>                                   // §5.1
    <ImportButton onClick={openImportModal} />
    <ExportButton onClick={exportAddress} />
    <AddProvinceButton onClick={addProvince} />
  </ActionBar>
  <HierarchySchema />                           // Visual: Province → District → Subdistrict → Postal
  <StatsBar>                                    // §5.2 — 4 stat cards
    <StatCard ico="🏛️" label="จังหวัด" />
    <StatCard ico="🏘️" label="อำเภอ/เขต" />
    <StatCard ico="📌" label="ตำบล/แขวง" />
    <StatCard ico="📮" label="รหัสไปรษณีย์" />
  </StatsBar>
  <SplitLayout>
    <TreePanel>                                 // §5.3 · L · 380px
      <SearchInput onInput={filterTree} />
      <TreeVirtualized>                         // react-window
        <ProvinceNode> <DistrictNode> <SubdistrictNode> </...>
      </TreeVirtualized>
    </TreePanel>
    <DetailPanel>                               // §5.4 · R · flex 1
      <EmptyState />                            // ถ้ายังไม่ได้เลือก
      <AddressForm>                             // RHF + Zod
        <CodeField readonly />
        <ThNameField />
        <EnNameField + RomanizeBtn />
        <PostalField />                         // เฉพาะ subdistrict
        <ActiveSwitch />
      </AddressForm>
      <ActionBar>
        <SaveBtn /> <DeleteBtn /> <ResetBtn />
      </ActionBar>
    </DetailPanel>
  </SplitLayout>
  <ImportModal>                                 // §5.7 — drag-drop + preview
    <FileDropzone />
    <PreviewTable />                            // first 20 rows + validation errors
    <ImportProgress />
  </ImportModal>
  <DeleteConfirmModal />                        // §5.8 — cascade warning
</AddressPage>

📐 Layout Grid (Desktop ≥1280px)

ZoneWidthComponent
Header100%Title + ActionBar (right-aligned)
Hierarchy schema100%Pills + arrows · 4 levels visual
Stats100% · 4 cols4 × StatCard
Tree panel380px (fixed)Search + Virtualized tree · max height 600px
Detail panelflex 1Form 2-cols + ActionBar
Import Modal720pxDropzone + Preview table + Progress

📥04Base Loads (Initial)

ข้อมูลที่ต้องโหลดเมื่อเปิดหน้าครั้งแรก

🔄 RSC Initial Fetch

// app/setting/master/address/page.tsx
import { getAddressTree, getAddressStats } from "@/lib/api/addresses";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";

export default async function AddressPage() {
  const session = await auth();
  if (!session)                                redirect("/login");
  if (session.user.role !== "platform_admin")  redirect("/403?reason=address");

  // Parallel fetch — RSC streams
  const [tree, stats] = await Promise.all([
    getAddressTree(),       // GET /api/v1/addresses/tree (province-only · lazy load children)
    getAddressStats(),      // GET /api/v1/addresses/stats
  ]);

  return <AddressClient initialTree={tree} initialStats={stats} />;
}

📊 API Calls on Mount

#EndpointPurposeCache
1GET /api/v1/addresses/treeโหลด province list ทั้งหมด (77 records) · ไม่รวม childrenRedis 24h · SWR
2GET /api/v1/addresses/stats4 counters: provinces · districts · subdistricts · unique postalRedis 24h · count materialized
3GET /api/v1/addresses/tree?parent={province_id}Lazy load districts ของ province · เมื่อ user expandRedis 24h · per-province
4GET /api/v1/addresses/tree?parent={district_id}Lazy load subdistricts ของ districtRedis 24h

📥 Sample Response — /api/v1/addresses/tree (provinces)

{
  "data": [
    {
      "id": "50",                       // TIS-1099 2-digit province code
      "type": "province",
      "code": "50",
      "name_th": "เชียงใหม่",
      "name_en": "Chiang Mai",
      "active": true,
      "child_count": 25,                // districts count
      "version": 1,
      "updated_at": "2025-01-15T00:00:00Z"
    },
    {
      "id": "40",
      "type": "province",
      "code": "40",
      "name_th": "ขอนแก่น",
      "name_en": "Khon Kaen",
      "active": true,
      "child_count": 26,
      "version": 1
    },
    /* ... 77 provinces ... */
  ],
  "meta": { "total": 77 }
}

⚡ Client Hydration (SWR + Virtualization)

"use client";
import useSWR from "swr";
import { FixedSizeList } from "react-window";

export default function AddressClient({ initialTree, initialStats }) {
  const { data: tree, mutate: mutateTree } = useSWR("/api/v1/addresses/tree", fetcher,
    { fallbackData: initialTree, dedupingInterval: 86_400_000 }); // 24h
  const { data: stats } = useSWR("/api/v1/addresses/stats", fetcher,
    { fallbackData: initialStats, dedupingInterval: 86_400_000 });

  // Flatten tree to array for react-window (compute only expanded + filter)
  const visibleRows = useMemo(() => flattenVisible(tree, expanded, filter), [tree, expanded, filter]);

  return (
    <FixedSizeList
      height={600} itemCount={visibleRows.length} itemSize={32}>
      {({ index, style }) => (
        <TreeRow style={style} item={visibleRows[index]} onExpand={lazyLoad} />
      )}
    </FixedSizeList>
  );
}

async function lazyLoad(parentId: string) {
  const children = await api.get(`/api/v1/addresses/tree?parent=${parentId}`);
  // merge into tree state
}
💡 Performance trick: Initial load = 77 provinces only · child levels load on expand · max DOM ~100 rows (react-window) · UI smooth แม้มี 7,400+ records ทั้งหมด

🎯05Component → API Mapping

รายละเอียดทุก action ที่กดบนหน้าจอ + API ที่เรียก + UI behavior

5.1 ActionBar ปุ่ม "➕ เพิ่มจังหวัด"

🎬 Behavior

  • กด → เปิด DetailPanel mode="create-province" · clear form
  • Code field editable (province 2-digit)
  • กดบันทึก → POST /addresses · type=province
  • หลังสร้างสำเร็จ → append ใน tree · scroll to + auto-select
POST/api/v1/addresses
Create address (any level) · body: {type, code, name_th, name_en, parent_id?, postal_code?} · Idempotency-Key required

5.2 StatsBar — 4 Counters

🎬 Behavior

  • โหลดจาก /addresses/stats · update เมื่อ create/delete
  • SWR mutate ใน optimistic — เพิ่ม/ลด counter ทันที
  • Re-fetch หลัง CSV import เสร็จ
GET/api/v1/addresses/stats
Response: {provinces, districts, subdistricts, postal_codes} · materialized count · cached 24h

5.3 TreePanel — Search + Virtualized Tree

📸 Capture · Tree Row Hover + Selected
▼ 🏛️ เชียงใหม่ 50 · 25 อำเภอ
▼ 🏘️ อำเภอเมืองเชียงใหม่ 5001
📌 ตำบลศรีภูมิ 500101 · 50200

🎬 Behavior

  • Click row → highlight + load DetailPanel
  • Click chevron ▶/▼ → expand/collapse
  • Expand province → lazy GET children (lazy load · 50ms shimmer)
  • react-window virtualize · render < 100 DOM rows แม้ tree มี 7,400
  • Search input → debounce 200ms → filter ทั้ง 3 levels (TH + EN + postal)
  • เมื่อ filter match → auto-expand parent + highlight match yellow
// TreePanel.tsx
const onSearchInput = useDebouncedCallback((q: string) => {
  if (q.length < 2) {
    addressStore.setFilter(null);
    return;
  }
  // Server-side fuzzy search for results > current tree memory
  api.get(`/api/v1/addresses/search?q=${encodeURIComponent(q)}&limit=50`)
    .then(matches => {
      // Auto-expand ancestors of matches
      const ancestorIds = new Set();
      matches.forEach(m => {
        if (m.province_id) ancestorIds.add(m.province_id);
        if (m.district_id) ancestorIds.add(m.district_id);
      });
      addressStore.setFilter({ q, matches, ancestorIds });
      ancestorIds.forEach(id => lazyLoadIfMissing(id));
    });
}, 200);
GET/api/v1/addresses/search?q={q}&limit=50
Fuzzy search pg_trgm · match name_th, name_en, postal_code · returns flat list with ancestor IDs

5.4 DetailPanel — แก้ไขข้อมูล

📸 Capture · DetailPanel Edit Mode
ตำบล/แขวง · TIS-1099 500101
📌 ตำบลศรีภูมิ
↺ Reset 🗑️ ลบ 💾 บันทึก
🔄
เปิดใช้งาน

🎬 Behavior

  • เลือก row ใน tree → GET /addresses/:id · populate form
  • Code field always read-only (TIS-1099 immutable)
  • name_th input → onBlur → call Romanizer · suggest EN ใน input (highlight)
  • Postal field — เฉพาะ subdistrict level
  • Active switch → ทันที update (optimistic)
  • กด 💾 → Zod validate → PATCH · If-Match version
  • กด ↺ Reset → revert local changes
  • กด 🗑️ ลบ → DeleteConfirmModal (cascade warning)
GET/api/v1/addresses/{id}
Get single address with full hierarchy info (parent · child_count)
PATCH/api/v1/addresses/{id}
Update · If-Match version · partial · body: {name_th?, name_en?, postal_code?, active?}

5.5 ปุ่ม "🔄 Auto Romanize"

🎬 Behavior

  • กดหรือ name_th onBlur → ส่งไป /addresses/romanize
  • Server ใช้ th-romanize library (RTGS standard)
  • คืน suggestion · client เติมใน name_en field
  • User edit ทับได้ — ระบบจำว่ามี name_en_overridden=true
GET/api/v1/addresses/romanize?text={th_text}
Romanize TH → EN ตาม Royal Thai General System · response: {romanized}

5.6 Switch "สถานะ Active"

🎬 Behavior

  • Toggle → optimistic update + PATCH ทันที
  • ปิด active บน province → confirm modal: "ปิดทั้งจังหวัดจะทำให้ทุก district/subdistrict ภายในใช้ไม่ได้ใน dropdown ต่างๆ"
  • Subdistrict ที่ active=false → ยังคงอยู่ในระบบเก่า · ไม่ปรากฏใน typeahead ใหม่
  • Active flag = soft hide (ไม่ใช่ delete)

5.7 ปุ่ม "📥 นำเข้า CSV"

🎬 Behavior

  • เปิด ImportModal · drag-drop หรือ click upload
  • SheetJS ฝั่ง browser อ่านไฟล์ → preview 20 rows แรก
  • Validate ทุก row ฝั่ง client ก่อนส่ง — แสดง errors ใน preview table
  • ถ้า valid ≥ 1 row → enable "นำเข้า" button
  • กด "นำเข้า" → POST multipart · backend ใช้ pgx.CopyFrom bulk insert
  • Progress bar แสดง % · ETA
  • สำเร็จ → refresh tree + stats · audit log "addresses.imported"
  • Mode: append (insert ใหม่ · skip duplicate) หรือ upsert (update existing by code)
POST/api/v1/addresses/import
multipart: {file, mode=append|upsert, dry_run?} · response: {inserted, updated, skipped, errors[]} · streaming JSONL for progress
// ImportModal.tsx
const onFileSelect = async (file: File) => {
  // 1) Parse client-side (SheetJS)
  const arrayBuffer = await file.arrayBuffer();
  const workbook = XLSX.read(arrayBuffer, { type: 'array' });
  const rows = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]);
  
  // 2) Validate client-side (Zod)
  const errors = [];
  const validRows = [];
  rows.forEach((row, idx) => {
    const result = addressCsvSchema.safeParse(row);
    if (result.success) validRows.push(result.data);
    else errors.push({ line: idx + 2, ...result.error.flatten() });
  });
  
  setPreview({ total: rows.length, valid: validRows.length, errors });
};

const onImport = async () => {
  const formData = new FormData();
  formData.append('file', selectedFile);
  formData.append('mode', mode);
  
  // 3) Stream upload + progress
  const response = await fetch('/api/v1/addresses/import', {
    method: 'POST', body: formData,
    headers: { 'Idempotency-Key': uuidv4() },
  });
  
  const reader = response.body.getReader();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const progress = JSON.parse(new TextDecoder().decode(value));
    setProgress(progress.percent); // 0-100
  }
  
  mutate('/api/v1/addresses/tree');
  mutate('/api/v1/addresses/stats');
  message.success('นำเข้าเรียบร้อย');
};

5.8 ปุ่ม "📤 ส่งออก"

🎬 Behavior

  • Modal เลือก format (CSV · XLSX · JSON) + filter (active only · all · provinces only)
  • Stream download · Content-Disposition: attachment
  • UTF-8 BOM สำหรับ Excel ภาษาไทย
  • Filename: addresses_2026-06-03.csv
GET/api/v1/addresses/export?format=csv&active_only=true
Stream CSV/XLSX/JSON · UTF-8 BOM · response cached on CDN (24h)

5.9 ปุ่ม "🗑️ ลบ"

🎬 Behavior

  • Modal warning — ระบุจำนวน children ที่จะถูก cascade
  • ตรวจการใช้งาน — ถ้ามี organizations.address.subdistrict_id ใช้อยู่ → 409 + แสดง count + ปุ่ม "ดูที่ใช้งาน"
  • Soft delete · deleted_at = NOW() · cascade children
  • Audit log + NATS broadcast cache invalidation
DELETE/api/v1/addresses/{id}?cascade=true
Soft delete · cascade=true required for non-leaf · 409 ถ้ามี FK references
GET/api/v1/addresses/{id}/references
List organizations/users/projects ที่ใช้ address นี้ · เพื่อ resolve ก่อนลบ

5.10 Cache Invalidation Broadcast

🎬 Behavior

  • ทุก mutation (create/update/delete/import) → backend publish NATS address.changed
  • Subscribers: Cache service (Redis flush) · CDN purge (Cloudflare) · Frontend WS (revalidate SWR)
  • Frontend ของผู้ใช้อื่น (กำลังใช้หน้า Company Info) → typeahead cache invalidate

🔌06API Reference

#MethodPathDescriptionAuth
1GET/api/v1/addresses/treeList provinces (or children of parent)JWT
2GET/api/v1/addresses/{id}Single address with hierarchyJWT
3GET/api/v1/addresses/stats4 countersJWT
4GET/api/v1/addresses/searchFuzzy search · pg_trgm · all levelsJWT
5GET/api/v1/addresses/subdistrictsPublic typeahead · used by Company Info etc.JWT
6GET/api/v1/addresses/postal/{postal}Reverse: postal → subdistricts listJWT
7GET/api/v1/addresses/romanizeTH → EN romanize · RTGS standardJWT
8GET/api/v1/addresses/{id}/referencesFK references count + samplesAdmin
9POST/api/v1/addressesCreate · Idempotency-KeyAdmin
10PATCH/api/v1/addresses/{id}Update · If-Match versionAdmin
11DELETE/api/v1/addresses/{id}Soft delete · cascade param · 409 if FKAdmin
12POST/api/v1/addresses/importBulk import CSV/XLSX · streaming progressAdmin
13GET/api/v1/addresses/exportStream CSV/XLSX/JSON · UTF-8 BOMAdmin
14POST/api/v1/addresses/bulk-update-postalMass postal update · admin only (rare)Admin

📦 Go Struct — Address

type Address struct {
  ID             string     `json:"id"`             // TIS-1099 code (2/4/6 digits)
  Type           string     `json:"type"            validate:"required,oneof=province district subdistrict"`
  Code           string     `json:"code"            validate:"required,numeric,len=2|len=4|len=6"`
  ParentID       *string    `json:"parent_id"`      // null for province
  NameTh         string     `json:"name_th"         validate:"required,max=100"`
  NameEn         string     `json:"name_en"         validate:"required,max=100,ascii"`
  NameEnAuto     bool       `json:"name_en_auto"`   // true = generated by romanize · user can override
  PostalCode     *string    `json:"postal_code"     validate:"omitempty,len=5,numeric"`
  Active         bool       `json:"active"          validate:"-"`
  ChildCount     int        `json:"child_count"`    // computed
  Version        int        `json:"version"`        // optimistic lock
  CreatedAt      time.Time
  UpdatedAt      time.Time
  DeletedAt      *time.Time
}

// Validation:
// - province: code = 2 digits · parent_id = null · postal_code = null
// - district: code = 4 digits · parent_id = province (2-digit) · postal_code = null
// - subdistrict: code = 6 digits · parent_id = district (4-digit) · postal_code = 5 digits required

🐘 DB Schema

CREATE TABLE th_addresses (
  id              VARCHAR(6) PRIMARY KEY,        -- TIS-1099 code (2 · 4 · 6 digits)
  type            VARCHAR(16) NOT NULL CHECK (type IN ('province','district','subdistrict')),
  code            VARCHAR(6) NOT NULL,
  parent_id       VARCHAR(6) REFERENCES th_addresses(id),
  name_th         VARCHAR(100) NOT NULL,
  name_en         VARCHAR(100) NOT NULL,
  name_en_auto    BOOLEAN NOT NULL DEFAULT true,
  postal_code     CHAR(5),
  active          BOOLEAN NOT NULL DEFAULT true,
  version         INT NOT NULL DEFAULT 1,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  deleted_at      TIMESTAMPTZ,
  
  CHECK (
    (type = 'province'    AND char_length(code) = 2 AND parent_id IS NULL AND postal_code IS NULL) OR
    (type = 'district'    AND char_length(code) = 4 AND parent_id IS NOT NULL AND char_length(parent_id) = 2 AND postal_code IS NULL) OR
    (type = 'subdistrict' AND char_length(code) = 6 AND parent_id IS NOT NULL AND char_length(parent_id) = 4 AND postal_code IS NOT NULL)
  )
);

-- GIN trigram indexes for fuzzy search
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_addr_name_th_trgm ON th_addresses USING GIN (name_th gin_trgm_ops) WHERE deleted_at IS NULL;
CREATE INDEX idx_addr_name_en_trgm ON th_addresses USING GIN (name_en gin_trgm_ops) WHERE deleted_at IS NULL;
CREATE INDEX idx_addr_postal       ON th_addresses (postal_code) WHERE deleted_at IS NULL;
CREATE INDEX idx_addr_parent       ON th_addresses (parent_id, active) WHERE deleted_at IS NULL;

-- Materialized count
CREATE MATERIALIZED VIEW mv_address_stats AS
SELECT
  (SELECT COUNT(*) FROM th_addresses WHERE type='province'    AND deleted_at IS NULL AND active) AS provinces,
  (SELECT COUNT(*) FROM th_addresses WHERE type='district'    AND deleted_at IS NULL AND active) AS districts,
  (SELECT COUNT(*) FROM th_addresses WHERE type='subdistrict' AND deleted_at IS NULL AND active) AS subdistricts,
  (SELECT COUNT(DISTINCT postal_code) FROM th_addresses WHERE postal_code IS NOT NULL AND deleted_at IS NULL) AS postal_codes,
  NOW() AS snapshot_at;

📊07Data Flow Diagram (DFD)

การไหลของข้อมูลในกระบวนการ CRUD · import · cache invalidation

flowchart LR User([👤 Platform Admin]) UI[⚛️ Next.js
Address Page] Tree[🌲 react-window
Virtualized Tree] Form[📝 RHF + Zod] Store[(🗄️ Zustand
addressStore)] API[⚙️ Go-Chi API
/api/v1/addresses/*] Auth{🛡️ Keycloak
JWT verify} Validator[✅ validator/v10
TIS-1099] PG[(🐘 PostgreSQL
th_addresses
pg_trgm GIN)] Redis[(⚡ Redis
addr:tree:* TTL 24h)] CDN[(☁️ Cloudflare CDN
public JSON)] MinIO[(📦 MinIO
backup archives)] Sheet[📊 SheetJS
client parse] Roman[🔤 th-romanize
RTGS] NATS[/📡 NATS
address.changed/] OtherFE[🌐 Other clients
Company Info · User · etc.] Audit[(📋 audit_logs)] User -->|view| UI UI -->|expand node| API API --> Auth Auth -->|role=platform_admin| Validator API --> Redis Redis -.->|miss| PG PG --> Redis Redis --> API API --> UI UI --> Tree User -->|edit| Form Form -->|onBlur TH| API API --> Roman Roman --> API API --> Form Form -->|save| API Validator -->|valid| PG PG --> Audit API --> NATS NATS --> Redis NATS --> CDN NATS --> OtherFE User -->|import CSV| Sheet Sheet -->|preview| UI UI -->|valid file| API API -->|CopyFrom| PG PG --> MinIO User -->|export| API API --> CDN CDN --> User

🔄 ขั้นตอนสำคัญ

  1. Tree load: Lazy load per level · Redis 24h cache · 1 query แต่ละ parent
  2. Edit: name_th → Romanize TH→EN (RTGS) → fill EN field
  3. Save: Validator (TIS-1099 + uniqueness) → PG UPDATE + Audit + NATS
  4. CSV Import: Client SheetJS parse + validate → server CopyFrom (bulk) → NATS broadcast
  5. Cache invalidation: NATS event → Redis flush + Cloudflare purge + other clients revalidate
  6. Export: Stream from PG · CDN edge cache · serve world-wide

🔄08State Machine

8.1 Address Lifecycle

stateDiagram-v2 [*] --> active : POST (create) active --> inactive : PATCH active=false inactive --> active : PATCH active=true active --> deleted : DELETE (soft) inactive --> deleted : DELETE (soft) deleted --> active : POST /restore (within 30d) deleted --> [*] : auto purge after 90d note right of active ปรากฏใน typeahead Used by Company/User/etc. end note note right of inactive ซ่อนจาก typeahead ของเก่ายังคงอ้างอิงได้ end note note right of deleted Soft delete only Cascade to children Audit kept forever end note

8.2 CSV Import Lifecycle

stateDiagram-v2 [*] --> Idle Idle --> FileSelected : drop file FileSelected --> ClientParsing : SheetJS read ClientParsing --> InvalidFormat : not CSV/XLSX ClientParsing --> ClientValidating : parsed ClientValidating --> PreviewReady : validate done PreviewReady --> Idle : cancel PreviewReady --> Uploading : click Import Uploading --> ServerValidating : streaming upload ServerValidating --> Inserting : pgx CopyFrom Inserting --> Completed : all rows ok Inserting --> PartialFailed : some failed (rollback or partial) Completed --> Idle : close modal PartialFailed --> Idle : view errors · download log

8.3 Tree Node Lazy Load

stateDiagram-v2 [*] --> Collapsed Collapsed --> Loading : user clicks ▶ Loading --> Expanded : children received Loading --> Failed : API error Expanded --> Collapsed : user clicks ▼ Failed --> Loading : retry Expanded --> Refreshing : address.changed event Refreshing --> Expanded : new data merged

↕️09Sequence Diagrams

9.1 Lazy Load Tree + Cache Hit

sequenceDiagram actor U as 👤 Admin participant UI as ⚛️ Next.js participant API as ⚙️ Go API participant R as ⚡ Redis participant PG as 🐘 PG U->>UI: Click ▶ on "เชียงใหม่" UI->>UI: setLoading(true) · shimmer UI->>API: GET /addresses/tree?parent=50 API->>R: GET addr:tree:50 alt cache hit R-->>API: districts[] (25 rows) else cache miss R-->>API: nil API->>PG: SELECT * FROM th_addresses WHERE parent_id='50' AND deleted_at IS NULL PG-->>API: 25 districts API->>R: SETEX addr:tree:50 86400 districts (24h) end API-->>UI: 200 OK · districts[] UI->>UI: merge into tree state · render UI-->>U: expand animation

9.2 Edit + Romanize Auto-suggest

sequenceDiagram actor U as 👤 Admin participant UI as ⚛️ Next.js participant API as ⚙️ Go API participant Roman as 🔤 th-romanize participant PG as 🐘 PG U->>UI: Edit name_th = "ตำบลศรีภูมิ" U->>UI: Tab/blur out UI->>UI: name_en_auto = true (no user override) UI->>API: GET /addresses/romanize?text=ตำบลศรีภูมิ API->>Roman: Romanize("ตำบลศรีภูมิ") Roman-->>API: "Si Phum" (skip "ตำบล" prefix) API-->>UI: { romanized: "Si Phum" } UI->>UI: setValue('name_en', 'Si Phum') U->>UI: Click 💾 บันทึก UI->>UI: Zod validate UI->>API: PATCH /addresses/500101 · If-Match: 3 API->>PG: UPDATE th_addresses SET name_th=$1, name_en=$2, name_en_auto=true, version+1 PG-->>API: 1 row API->>PG: INSERT audit_logs ('address.updated') API-->>UI: 200 OK · { address, new_version } UI->>UI: SWR mutate · clear dirty

9.3 CSV Import with Streaming Progress

sequenceDiagram actor U as 👤 Admin participant UI as ⚛️ Next.js participant Sheet as 📊 SheetJS participant API as ⚙️ Go API participant PG as 🐘 PG participant N as 📡 NATS U->>UI: Drop addresses.xlsx (7,400 rows) UI->>Sheet: XLSX.read(file) Sheet-->>UI: rows[] UI->>UI: Zod validate each row · count errors UI-->>U: Preview "7,400 rows · 12 errors · skip → 7,388 ok" U->>UI: Click "นำเข้า" · mode=upsert UI->>API: POST /addresses/import (multipart · streaming) API->>PG: BEGIN Note over API,PG: pgx.CopyFrom bulk insert loop chunks of 500 rows API->>PG: COPY th_addresses FROM STDIN PG-->>API: ack API-->>UI: stream progress {percent: X, inserted: N} UI->>UI: progress bar update end API->>PG: COMMIT API->>PG: REFRESH MATERIALIZED VIEW mv_address_stats API->>PG: INSERT audit_logs ('addresses.imported') API->>N: PUBLISH address.changed N-->>R: FLUSH addr:tree:* N-->>CDN: PURGE /api/v1/addresses/* API-->>UI: 200 OK · {inserted: 7388, updated: 0, errors: 12} UI->>UI: mutate /tree · /stats UI-->>U: ✅ นำเข้า 7,388 รายการ · ข้าม 12 รายการ (ดู error log)

9.4 Delete with Cascade Check

sequenceDiagram actor U as 👤 Admin participant UI as ⚛️ Next.js participant API as ⚙️ Go API participant PG as 🐘 PG U->>UI: Click 🗑️ ลบ "เชียงใหม่" UI->>API: GET /addresses/50/references API->>PG: SELECT COUNT(*) FROM organizations WHERE (address->'subdistrict_id')::text LIKE '50%' PG-->>API: 3 organizations · 14 users · 8 projects API-->>UI: { orgs:3, users:14, projects:8, samples:[...] } UI-->>U: Modal "พบ 25 รายการใช้งานอยู่ · ห้ามลบ" + "ดูที่ใช้งาน" alt user proceeds anyway (force=false) U->>UI: Cancel else if no references UI-->>U: Modal "เชียงใหม่ มี 25 districts + 200 subdistricts · cascade ทั้งหมด · ดำเนินการ?" U->>UI: Confirm UI->>API: DELETE /addresses/50?cascade=true API->>PG: BEGIN API->>PG: UPDATE th_addresses SET deleted_at=NOW() WHERE id='50' OR parent_id LIKE '50%' OR parent_id IN (SELECT id WHERE parent LIKE '50%') API->>PG: COMMIT API-->>UI: 200 OK · {deleted: 225} UI->>UI: mutate tree end

10Form Validation

Frontend (Zod) + Backend (validator/v10 + custom Thai validators)

🧪 Zod Schema

// schemas/address.ts
import { z } from "zod";

const provinceCode    = z.string().regex(/^[0-9]{2}$/, "รหัส 2 หลัก");
const districtCode    = z.string().regex(/^[0-9]{4}$/, "รหัส 4 หลัก");
const subdistrictCode = z.string().regex(/^[0-9]{6}$/, "รหัส 6 หลัก");
const postalCode      = z.string().regex(/^[0-9]{5}$/, "รหัสไปรษณีย์ 5 หลัก");

export const addressSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("province"),
    code: provinceCode,
    parent_id: z.null(),
    name_th: z.string().min(2).max(100).regex(/^[฀-๿\s.]+$/, "ภาษาไทยเท่านั้น"),
    name_en: z.string().min(2).max(100).regex(/^[A-Za-z\s.'-]+$/, "อังกฤษเท่านั้น"),
    postal_code: z.null(),
    active: z.boolean(),
  }),
  z.object({
    type: z.literal("district"),
    code: districtCode,
    parent_id: provinceCode,                       // FK province
    name_th: z.string().min(2).max(100).regex(/^[฀-๿\s.]+$/),
    name_en: z.string().min(2).max(100).regex(/^[A-Za-z\s.'-]+$/),
    postal_code: z.null(),
    active: z.boolean(),
  }).refine(d => d.code.startsWith(d.parent_id), {
    message: "รหัส district 4 หลักต้องขึ้นต้นด้วยรหัส province 2 หลัก",
    path: ["code"]
  }),
  z.object({
    type: z.literal("subdistrict"),
    code: subdistrictCode,
    parent_id: districtCode,                       // FK district
    name_th: z.string().min(2).max(100).regex(/^[฀-๿\s.]+$/),
    name_en: z.string().min(2).max(100).regex(/^[A-Za-z\s.'-]+$/),
    postal_code: postalCode,                       // required
    active: z.boolean(),
  }).refine(d => d.code.startsWith(d.parent_id), {
    message: "รหัส subdistrict 6 หลักต้องขึ้นต้นด้วยรหัส district 4 หลัก",
    path: ["code"]
  }),
]);

// CSV row schema (more lenient)
export const addressCsvSchema = z.object({
  type: z.enum(["province","district","subdistrict"]),
  code: z.string().regex(/^[0-9]{2,6}$/),
  parent_id: z.string().regex(/^[0-9]{2,4}$/).optional(),
  name_th: z.string().min(1).max(100),
  name_en: z.string().min(1).max(100),
  postal_code: z.string().regex(/^[0-9]{5}$/).optional(),
  active: z.coerce.boolean().default(true),
});

🔍 Field-by-Field Rules

FieldRequiredRuleError Message (TH)
typeenum [province · district · subdistrict]
code2/4/6 digits ตาม type · ขึ้นต้นด้วย parent code · TIS-1099"รหัสไม่ตรงกับ TIS-1099"
parent_id⚠️ conditionalFK th_addresses · null for province · required for district/subdistrict"กรุณาเลือก parent"
name_th2–100 chars · Thai chars only (Unicode 0E00–0E7F)"ใช้ภาษาไทยเท่านั้น"
name_en2–100 chars · ASCII A-Z space . ' -"ใช้ภาษาอังกฤษเท่านั้น"
postal_code⚠️ conditional5 digits · required for subdistrict · null for others"รหัสไปรษณีย์ 5 หลัก"
activeboolean
Code uniquenessasync check ผ่าน GET /addresses/check-code"รหัสซ้ำในระบบ"
Province name uniquenessname_th unique ทั่ว province"ชื่อจังหวัดซ้ำ"

📏 Custom Backend Validators

// validator/v10 custom registrations
validate.RegisterValidation("tis1099_code", func(fl validator.FieldLevel) bool {
  code := fl.Field().String()
  parent := fl.Parent().FieldByName("ParentID").Interface().(*string)
  t := fl.Parent().FieldByName("Type").String()
  
  switch t {
  case "province":
    return len(code) == 2 && parent == nil
  case "district":
    return len(code) == 4 && parent != nil && len(*parent) == 2 && strings.HasPrefix(code, *parent)
  case "subdistrict":
    return len(code) == 6 && parent != nil && len(*parent) == 4 && strings.HasPrefix(code, *parent)
  }
  return false
})

validate.RegisterValidation("thai_chars", func(fl validator.FieldLevel) bool {
  text := fl.Field().String()
  return regexp.MustCompile(`^[฀-๿\s.]+$`).MatchString(text)
})

validate.RegisterValidation("rtgs_english", func(fl validator.FieldLevel) bool {
  text := fl.Field().String()
  return regexp.MustCompile(`^[A-Za-z\s.'\-]+$`).MatchString(text)
})

🛡️11RBAC & Permissions

per Role&Privilege.docx · "ที่อยู่" = R/C/E/D เฉพาะ Platform Admin

ActionEndpointPlatform AdminAll Others
View treeGET /tree✅ full · including inactive❌ no menu access
Public typeaheadGET /subdistricts?q=active only · used by Company Info typeahead
Public postal lookupGET /postal/{postal}active only
RomanizeGET /romanize
Create / Update / DeletePOST · PATCH · DELETE❌ 403
Import CSVPOST /import
ExportGET /export
References lookupGET /:id/references

🛡️ Middleware Pattern

r.Route("/api/v1/addresses", func(r chi.Router) {
  r.Use(auth.RequireJWT)
  
  // Public read — accessible to all authenticated users (used by Company Info typeahead etc.)
  r.Get("/subdistricts",         handler.SearchSubdistricts)   // filters active only for non-admins
  r.Get("/postal/{postal}",      handler.LookupByPostal)
  
  // Admin-only routes
  r.Group(func(r chi.Router) {
    r.Use(rbac.RequireRole("platform_admin"))
    r.Get("/tree",         handler.GetTree)                    // includes inactive
    r.Get("/{id}",         handler.GetAddress)
    r.Get("/stats",        handler.GetStats)
    r.Get("/search",       handler.Search)
    r.Get("/romanize",     handler.Romanize)
    r.Get("/{id}/references", handler.GetReferences)
    r.Post("/",            handler.CreateAddress)
    r.Patch("/{id}",       handler.UpdateAddress)
    r.Delete("/{id}",      handler.DeleteAddress)
    r.Post("/import",      handler.ImportAddresses)
    r.Get("/export",       handler.ExportAddresses)
    r.Post("/bulk-update-postal", handler.BulkUpdatePostal)
  })
})

🚪 UI Guard

// app/setting/master/address/page.tsx
export default async function AddressPage() {
  const session = await auth();
  if (!session)                                redirect("/login");
  if (session.user.role !== "platform_admin")  redirect("/403?reason=address");
  // ...
}
💡 Smart split: Read endpoints แยกเป็น 2 กลุ่ม — /subdistricts และ /postal/:p เปิดให้ทุก role ใช้ (สำหรับ Company Info typeahead) · เฉพาะ admin จะเห็น tree management UI

12Performance

🎯 Targets

MetricTargetMeasure At
TTFB (RSC)≤ 200ms p95Vercel
LCP≤ 1.5sWeb Vitals
GET /tree (77 provinces)≤ 30ms cached · ≤ 80ms coldBackend trace
GET /tree?parent=X (lazy)≤ 50ms (Redis hit)Backend trace
Typeahead /subdistricts≤ 80ms p95 (pg_trgm GIN)Backend trace
CSV Import 7,400 rows≤ 8s (pgx.CopyFrom)Backend trace
Romanize call≤ 20ms (in-memory dict)Backend trace
Tree scroll (7,400 visible)≥ 60 fps · react-window virtLighthouse

⚡ Optimization Strategies

  • Lazy load tree — 77 provinces เริ่มต้น · children load on expand
  • react-window virtualization — render < 100 DOM rows
  • Redis cache 24h — address มี mutation rate ต่ำมาก (annual update)
  • pg_trgm GIN index — fuzzy search ≤ 80ms ที่ 7,400 records
  • pgx.CopyFrom — bulk import 7,400 rows ใน < 8s (vs INSERT loop ~5min)
  • Cloudflare CDN cache — /export response cached at edge worldwide
  • Materialized view — stats counts ไม่ต้องนับใหม่ทุกครั้ง
  • SWR dedupingInterval 24h — stable data ไม่ revalidate บ่อย
  • Romanize in-memory dict — load Thai dict (~50k entries) ที่ startup · O(1) lookup
💡 Why 24h cache? Thai address ข้อมูลเปลี่ยนแทบไม่บ่อย · DOPA update รายปี · เฉลี่ย 0-2 รายการ/เดือน · TTL 24h เหมาะที่สุด

🔒13Security & Compliance

🛡️ Threat Model

ThreatMitigation
Unauthorized mutationJWT + role=platform_admin enforce · 403 returned
Data leak via searchRead endpoints filter active=true for non-admin · admin sees all
CSV bomb (DoS via large file)Content-Length ≤ 5MB · row count limit 100,000 · streaming parse with backpressure
CSV injection (formula attack)SheetJS sanitize · prefix dangerous cells =, +, -, @ with apostrophe
SQL Injectionpgx parameterized 100% · ไม่ใช้ string concat
XSS (name_th display)React auto-escape · CSP · ไม่ใช้ dangerouslySetInnerHTML
Race condition (concurrent edit)Optimistic lock via version + If-Match
Cascade delete abuseReferences check (orgs/users/projects) ก่อน cascade · 409 if FK
Audit tamperingaudit_logs trigger ปฏิเสธ UPDATE/DELETE

📋 Audit Events

EventActionchanges (JSONB)
address.createdPOST /addresses{type, code, name_th, name_en}
address.updatedPATCH /addresses/:iddiff fields (no PII concern)
address.deletedDELETE /addresses/:id{cascade_count}
address.restoredPOST /addresses/:id/restoreNULL
addresses.importedPOST /addresses/import{file_name, rows_inserted, rows_updated, rows_failed}
addresses.exportedGET /addresses/export{format, filter, row_count}
bulk_postal.updatedPOST /bulk-update-postal{affected_count, from_postal, to_postal}

🇹🇭 Thai Compliance Reference

StandardCompliance Action
TIS-1099-2563code format · 2/4/6-digit hierarchy enforced ใน DB CHECK constraint
DOPA Annual UpdateImport CSV pattern · admin manually update รายปี (Phase 2: auto-sync)
Royal Romanizationname_en auto-suggest ตาม RTGS · ผู้ดูแลตรวจสอบ
Postal Code 5-digitregex enforced · มาตรฐานไปรษณีย์ไทย
PDPAAddress คือ public data (กรมการปกครอง) ไม่ใช่ PII · ไม่ต้อง consent

🔐 Sensitive Operations

  • Bulk delete — ตรวจ references ก่อนทุกครั้ง · cascade requires explicit flag
  • CSV import upsert — overwrite mode = require additional confirmation modal
  • Bulk postal update — เฉพาะ admin · audit log mandatory · email notify backup admin
  • Rate limit — POST/PATCH/DELETE: 60/min per admin
🚨 Audit Policy (Strict): changes JSONB ห้ามเก็บ password / OTP / credit card / government IDs · address ไม่มี PII concerns · audit_logs immutable

🧪14Testing & Acceptance

🎯 Acceptance Criteria

#Given / When / Then
AC-01G: Platform Admin · W: เปิด /setting/master/address · T: เห็น stats 77/928/7400+/5400+ · tree 77 provinces · TTFB ≤ 200ms
AC-02G: Non-admin · W: เปิด URL ตรง · T: redirect /403
AC-03G: Click ▶ on province · W: expand · T: GET /tree?parent=50 · districts โหลด ≤ 50ms (cache hit)
AC-04G: Tree filter "ทุ่ง" · W: รอ 200ms · T: ค้น pg_trgm · auto-expand ancestors · highlight match
AC-05G: Select subdistrict · W: click · T: GET /addresses/500101 · form populate
AC-06G: Edit name_th · W: blur · T: GET /romanize · suggest EN ใน input
AC-07G: Subdistrict code ไม่ขึ้นต้นด้วย district code · W: save · T: Zod error "รหัส 6 หลักต้องขึ้นต้นด้วย 4 หลักของ parent"
AC-08G: Postal 4 digits · W: save subdistrict · T: error "รหัสไปรษณีย์ 5 หลัก"
AC-09G: Toggle active=false on province · W: toggle · T: confirm modal "ปิดทั้งจังหวัด"
AC-10G: 2 admins แก้พร้อมกัน · W: save คนที่สอง · T: 412 + prompt refresh
AC-11G: Drop CSV 7400 rows · W: parse · T: SheetJS อ่าน ≤ 2s · preview 20 rows · validate errors
AC-12G: Import CSV upsert · W: click นำเข้า · T: pgx.CopyFrom · streaming progress · เสร็จ ≤ 8s · NATS broadcast
AC-13G: Delete province with 25 children · W: delete · T: references check · confirm cascade · soft delete 225 rows
AC-14G: Delete province + orgs ใช้อยู่ · W: delete · T: 409 + แสดง references count · ปุ่ม "ดูที่ใช้งาน"
AC-15G: Export CSV active_only · W: click · T: file UTF-8 BOM · row count ตรง stats
AC-16G: Mutation event · W: NATS publish · T: Redis flush + Cloudflare purge · other clients see update ภายใน 5s
AC-17G: Typeahead from Company Info (non-admin) · W: search "ทุ่ง" · T: GET /subdistricts · เห็นเฉพาะ active=true
AC-18G: Tree scroll 7,400 rows expanded · W: scroll · T: ≥ 60 fps · react-window virtualization works

🧪 Test Plan

LayerToolCoverage
Frontend UnitVitest + Testing Library≥ 85% · Zod schemas · TreeRow · DetailPanel · ImportModal
Frontend E2EPlaywright18 ACs · multi-tab edit race
Backend UnitGo testing + testify≥ 90% · handler · romanize · TIS-1099 validators
Backend Integrationdockertest + PG (pg_trgm) + Redis + NATSreal flow · CopyFrom · cache invalidation
TIS-1099 validatorproperty-based (gopter)1000 random codes · valid + invalid
Romanizefixture test · DOPA official names1000 sample names · ≥ 95% match
CSV Importreal DOPA file (7,400 rows)full upsert + dry-run + error handling
Performancek6 + DevToolstree scroll 60fps · typeahead p95 ≤ 80ms
SecurityOWASP ZAP + manualRBAC enforce · CSV injection · CSV bomb

📋 Definition of Done

  • ✅ ผ่าน AC-01 ถึง AC-18
  • ✅ Coverage ≥ 85% FE / ≥ 90% BE
  • ✅ Lighthouse ≥ 90 (Performance + A11y)
  • ✅ DOPA CSV 7,400 rows import ≤ 8s
  • ✅ pg_trgm typeahead p95 ≤ 80ms ที่ 500 RPS
  • ✅ Romanize ≥ 95% match DOPA official names
  • ✅ NATS broadcast → Cloudflare purge tested end-to-end
  • ✅ Audit log ครบ 7 events
  • ✅ Tree virtualization smooth 60fps
  • ✅ TIS-1099 validator property-tested (1000 random)
Definition of Ready (DoR): Tech stack อ่านจาก SystemArchitecture v2.0 · pg_trgm extension installed · th_addresses seeded จาก DOPA (7,400 records) · Romanize dict loaded · NATS topic address.changed agreed กับ Cache team · Cloudflare API key for purge ready
📍 ConstructQ — Address Developer Spec v1.0 · Generated 3 มิถุนายน 2569 · Doc ID: ADDR-SPEC-2026-001